22 — Raycaster 与鼠标事件

Three.js Journey · Bruno Simon · 难度:简单

22 — Raycaster 与鼠标事件 课程海报

介绍

顾名思义,一个 Raycaster 可以朝一个特定的方向投射(或射出)一条射线,并测试哪些物体与它相交。

你可以用这种技术来检测玩家面前是否有墙、测试激光枪是否打中了东西、测试鼠标当前是否悬停在某个东西上来模拟鼠标事件,以及许多其他事情。

设置

在我们的初始项目里,我们有 3 个红色球体,我们要射一条射线穿过它们,看看那些球体是否相交。

创建 Raycaster

实例化一个 Raycaster:

/**
 * Raycaster
 */
const raycaster = new THREE.Raycaster()

要改变射线投射的位置和方向,我们可以用 set(...) 方法。第一个参数是 position,第二个参数是 direction。

两者都是 Vector3,但 direction 必须被归一化。一个归一化的向量长度为 1。别担心,你不需要自己做数学计算,你可以在向量上调用 normalize() 方法:

const rayOrigin = new THREE.Vector3(- 3, 0, 0)
const rayDirection = new THREE.Vector3(10, 0, 0)
rayDirection.normalize()

raycaster.set(rayOrigin, rayDirection)

这个归一化向量的例子没什么实际意义,因为我们本可以把 10 设成 1,但如果改变这些值,normalize() 方法仍然会确保向量长度为 1 个单位。

这里,射线位置假设从我们场景中左边一点开始,方向似乎是向右。我们的射线应该穿过所有球体。

投射一条射线

要投射一条射线并获得相交的物体,我们可以用两个方法,intersectObject(...)(单数)和 intersectObjects(...)(复数)。

intersectObject(...) 会测试一个物体,而 intersectObjects(...) 会测试一个物体数组:

const intersect = raycaster.intersectObject(object2)
console.log(intersect)

const intersects = raycaster.intersectObjects([object1, object2, object3])
console.log(intersects)

如果你看日志,你会看到 intersectObject(...) 返回了一个包含一项的数组(很可能是第二个球体),而 intersectObjects(...) 返回了一个包含三项的数组(很可能是 3 个球体)。

相交的结果

相交的结果总是一个数组,即使你只测试一个物体。那是因为一条射线可以多次穿过同一个物体。想象一个甜甜圈。射线会穿过环的一部分,然后是中间的空洞,然后再穿过环的第二部分。

返回数组的每一项都包含很多有用的信息:

如何使用这些数据由你决定。如果你想测试玩家面前是否有墙,你可以测试 distance。如果你想改变物体的颜色,你可以更新 object 的材质。如果你想在撞击点显示一场爆炸,你可以在 point 位置创建这场爆炸。

在每一帧测试

目前,我们只在开始时投射一条射线。如果我们想在物体移动时测试东西,我们必须在每一帧做测试。让我们给球体加动画,并在射线与它们相交时把它们变成蓝色。

移除我们之前写的代码,只保留 raycaster 的实例化:

const raycaster = new THREE.Raycaster()

用经过的时间和经典的 Math.sin(...) 在 tick 函数里给球体加动画:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Animate objects
    object1.position.y = Math.sin(elapsedTime * 0.3) * 1.5
    object2.position.y = Math.sin(elapsedTime * 0.8) * 1.5
    object3.position.y = Math.sin(elapsedTime * 1.4) * 1.5

    // ...
}
lesson demo video preview

你应该看到球体以不同的频率上下摆动。

现在让我们像之前那样更新我们的 raycaster,但在 tick 函数里:

const clock = new THREE.Clock()

const tick = () =>
{
    // ...

    // Cast a ray
    const rayOrigin = new THREE.Vector3(- 3, 0, 0)
    const rayDirection = new THREE.Vector3(1, 0, 0)
    rayDirection.normalize()
    
    raycaster.set(rayOrigin, rayDirection)
    
    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)
    console.log(intersects)

    // ...
}

又一次,我们其实不需要归一化 rayDirection,因为它的长度已经是 1。但保留 normalize() 是好习惯,以防我们改变方向。

我们还把要测试的物体数组放进了变量 objectsToTest。这之后会很有用。

如果你看控制台,你应该得到一个包含相交的数组,而且这些相交会随着球体的位置不断变化。

我们现在可以更新 intersects 数组每一项的 object 属性的材质:

for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }
lesson demo video preview

不巧的是,它们都变蓝了,但永远不会变回红色。有很多方法可以把没有相交的物体变回红色。我们能做的是先把所有球体变红,然后把相交的变蓝:

for(const object of objectsToTest)
    {
        object.material.color.set('#ff0000')
    }

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }
lesson demo video preview

把 raycaster 用于鼠标

正如我们之前说的,我们也可以用 raycaster 来测试一个物体是否在鼠标后面。换句话说,你是否悬停在一个物体上。

从数学上讲,这有点复杂,因为我们需要从相机朝鼠标的方向投射一条射线,但幸运的是,Three.js 做了所有繁重的工作。

现在,让我们把 tick 函数里与 raycaster 相关的代码注释掉。

悬停

首先,让我们处理悬停。

开始的时候,我们需要鼠标的坐标。我们不能用原生 JavaScript 的基础坐标,那些是以像素为单位的。我们需要在两个水平轴和垂直轴上都从 -1 到 +1 的值,鼠标向上移动时垂直坐标为正。

这就是 WebGL 的工作方式,它和裁剪空间之类的东西有关,但我们不需要理解那些复杂的概念。

例子:

首先,让我们用一个 Vector2 创建 mouse 变量,并在鼠标移动时更新这个变量:

/**
 * Mouse
 */
const mouse = new THREE.Vector2()

window.addEventListener('mousemove', (event) =>
{
    mouse.x = event.clientX / sizes.width * 2 - 1
    mouse.y = - (event.clientY / sizes.height) * 2 + 1

    console.log(mouse)
})

看看日志,确保这些值和前面的例子吻合。

我们可以在 mousemove 事件回调里投射射线,但不推荐这样,因为对某些浏览器来说 mousemove 事件的触发频率可能比帧率更高。相反,我们会像之前那样在 tick 函数里投射射线。

要把射线朝向正确的方向,我们可以在 Raycaster 上使用 setFromCamera() 方法。剩下的代码和之前一样。我们只是根据是否相交把物体材质更新为红色或蓝色:

const tick = () =>
{
    // ...

    raycaster.setFromCamera(mouse, camera)
    
    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)
    
    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

    for(const object of objectsToTest)
    {
        if(!intersects.find(intersect => intersect.object === object))
        {
            object.material.color.set('#ff0000')
        }
    }

    // ...
}
lesson demo video preview

如果光标在球体上方,它们应该变成蓝色。

鼠标进入与离开事件

像 'mouseenter'、'mouseleave' 等等的鼠标事件也不被支持。如果你想在鼠标"进入"一个物体或"离开"那个物体时收到通知,你必须自己来做。

要重现 mouseenter 和 mouseleave 事件,我们能做的是用一个变量保存当前悬停的物体。

如果有一个物体相交,但之前没有,那意味着在那个物体上发生了 mouseenter。

如果没有物体相交,但之前有一个,那意味着发生了一次 mouseleave。

我们只需要保存当前相交的物体:

let currentIntersect = null

然后,测试并更新 currentIntersect 变量:

const tick = () =>
{
    // ...
    raycaster.setFromCamera(mouse, camera)
    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)
    
    if(intersects.length)
    {
        if(!currentIntersect)
        {
            console.log('mouse enter')
        }

        currentIntersect = intersects[0]
    }
    else
    {
        if(currentIntersect)
        {
            console.log('mouse leave')
        }
        
        currentIntersect = null
    }

    // ...
}

鼠标点击事件

现在我们有一个保存当前悬停物体的变量了,我们可以轻松实现一个 click 事件。

首先,我们需要监听 click 事件,无论它发生在哪里:

window.addEventListener('click', () =>
{
    
})

然后,我们可以测试 currentIntersect 变量里是否有东西:

window.addEventListener('click', () =>
{
    if(currentIntersect)
    {
        console.log('click')
    }
})

我们也可以测试点击涉及的是哪个物体:

window.addEventListener('click', () =>
{
    if(currentIntersect)
    {
        switch(currentIntersect.object)
        {
            case object1:
                console.log('click on object 1')
                break

            case object2:
                console.log('click on object 2')
                break

            case object3:
                console.log('click on object 3')
                break
        }
    }
})

重现原生事件需要花时间,但一旦你理解了它,就相当直截了当。

对模型进行 raycasting

这些都很好,但我们能对导入的模型应用 ray casting 吗?

答案是能,而且实际上相当容易。但我们要一起做,因为一路上有一些有意思的东西可以学习。

首先,我们需要一个模型。

加载模型

我们在之前课程里用过的 Duck 模型位于 static/models/Duck/ 文件夹里。

现在是个好时机,让你自己试着加载那个模型并把它添加到场景。

首先,我们要用 GLTFLoader。

从 three/examples/jsm/loaders/GLTFLoader.js 导入 GLTFLoader:

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'

接下来,我们需要实例化它。

你可以把这段代码放在实例化 scene 之后、tick 函数之前的任何地方:

/**
 * Model
 */
const gltfLoader = new GLTFLoader()

我们现在可以调用 load 方法了。两个参数是文件的路径和模型加载后应该被调用的函数。

我们会用 glTF-Binary,但随你使用其他版本。另外,别忘了如果你想用 Draco 压缩版本,你需要给 GLTFLoader 实例添加一个 DracoLoader 实例。

调用 load 方法,把 './models/Duck/glTF-Binary/Duck.glb' 作为路径传入(不带 static/ 路径),并传入一个里面带控制台日志的函数:

gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    () =>
    {
        console.log('loaded')
    }
)

你应该会在控制台里看到 'loaded'。

我们现在可以把模型添加到场景。首先,给函数添加一个 gltf 参数:

gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    (gltf) =>
    {
        console.log('loaded')
    }
)

现在,把 gltf.scene 属性里包含的整个加载场景 add 到你自己的 scene:

gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    (gltf) =>
    {
        scene.add(gltf.scene)
    }
)
lesson demo video preview

正如你看到的,有点问题。

灯光

如果你试着自己做,你大概在执行这一步时稍微挣扎了一下。

似乎有东西被添加到场景了,但全是黑的。原因是我们 Duck 的材质是一个 MeshStandardMaterial,而这类材质只有在有灯光时才能被看到。

让我们添加一个 AmbientLight 和一个 DirectionalLight:

/**
 * Lights
 */
// Ambient light
const ambientLight = new THREE.AmbientLight('#ffffff', 0.9)
scene.add(ambientLight)

// Directional light
const directionalLight = new THREE.DirectionalLight('#ffffff', 2.1)
directionalLight.position.set(1, 2, 3)
scene.add(directionalLight)

现在能看到 Duck 了,把它往下移一点:

gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    (gltf) =>
    {
        gltf.scene.position.y = - 1.2
        scene.add(gltf.scene)
    }
)

与模型相交

让我们在模型上试试 raycaster。

练习会相当简单。我们要让 Duck 在光标进入它时变大,在光标离开时恢复它正常的大小。

我们会在每一帧测试光标是否在 Duck 上,也就是说我们需要配置 tick 函数。raycaster 已经从鼠标设置好了,我们可以在我们用球体做的测试相关代码之后立刻做我们的相交测试。

之前,我们用 raycaster.intersectObjects 把 raycaster 对照一个 mesh 数组进行测试。但眼下,我们只测试一个物体,也就是 gltf.scene。是的,这个物体可能有多个 children,更糟的是还有 children 里的 children,但你会看到这不是问题,我们仍然是在测试一个物体。

与其用 intersectObjects(复数),我们要用 intersectObject(单数)。它工作方式完全一样,也会返回一个相交数组,但我们必须给它传一个物体而不是一个物体数组。

那么,你需要做什么?首先,创建一个 modelIntersects 变量(这样它就不会和 intersects 变量冲突),然后调用 raycaster.intersectObject(单数)方法,最后把 gltf.scene 传给它(这段代码不会生效):

const tick = () =>
{
    // ...

    // Test intersect with a model
    const modelIntersects = raycaster.intersectObject(gltf.scene)
    console.log(modelIntersects)

    // Update controls
    // ...
}

我们在这里犯了个错。如果你对 JS 很熟,你就知道我们不能从加载回调函数外面访问 gltf 变量。我们把这叫做变量的"作用域"。

另外,加载模型需要时间。是的,我们在本地用一个相当简单的模型测试,但情况可能不同,在线加载一个复杂物体会花时间。

这些都是当你试图与加载的模型交互或给它加动画时会遇到的经典问题。

要修复这两个问题,我们要在加载模型之前用 let 创建一个 model 变量,并把它设为 null(相当于 JavaScript 里的"什么都没有"):

let model = null
gltfLoader.load(
    // ...
)

因为我们在任何函数外面创建了这个 model 变量,我们就能在 tick 函数里使用它。

接下来,当模型加载好时,我们把 gltf.scene 赋值给那个 model:

let model = null
gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    (gltf) =>
    {
        model = gltf.scene
        gltf.scene.position.y = - 1.2
        scene.add(gltf.scene)
    }
)

我也想把那个加载函数里的 gltf.scene 替换成 model,因为那样更合理一点,尽管是可选的:

let model = null
gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    (gltf) =>
    {
        model = gltf.scene
        model.position.y = - 1.2
        scene.add(model)
    }
)

回到 tick 函数和我们的 intersectObject:我们现在可以用 model 变量代替 gltf.scene(这段代码还不会生效):

const tick = () =>
{
    // ...

    // Test intersect with a model
    const modelIntersects = raycaster.intersectObject(model)
    console.log(modelIntersects)

    // ...
}

又一次,我们得到一个错误。我们忘了加载模型需要时间,这意味着 model 变量会有一段时间是 null。

我们在这里能做的是简单地用一个 if 语句测试 model 里是否有东西:

const tick = () =>
{
    // ...

    if(model)
    {
        const modelIntersects = raycaster.intersectObject(model)
        console.log(modelIntersects)
    }

    // ...
}
lesson demo video preview

现在我们得到相交的数组了。

注意事项

在玩 Duck 的大小之前,有几件事要注意。

递归

首先,我们在 model 上调用 intersectObject,而它是一个 Group,不是 Mesh。

你可以在加载回调函数里赋值之前记录 model 来测试这一点:

let model = null
gltfLoader.load(
    './models/Duck/glTF-Binary/Duck.glb',
    (gltf) =>
    {
        model = gltf.scene
        console.log(model)
        model.position.y = - 1.2
        scene.add(model)
    }
)

这按理不该生效,因为 Raycaster 应该是对照 Mesh 测试的。它之所以能生效,是因为默认情况下,Raycaster 会检查对象的 children。更妙的是,它会递归地测试 children 的 children。

我们其实可以通过把 intersectObject 和 intersectObjects 方法的第二个参数设为 false,选择停用那个选项,但我们对默认行为很满意。

相交数组

第二件要注意的事是,我们只测试了一个物体,却收到一个相交数组。

第一个原因是,因为 Raycaster 递归地测试 children,所以可能有多个 Mesh 与射线相交。这里不是这种情况,因为 Duck 只由一个 Mesh 组成,但我们本可以测试一个更复杂的模型。

第二个原因是,正如我们之前看到的,即使一个 Mesh 也能与射线相交多次,而我们的 Duck 实际上就是这样。从一个非常特定的角度测试,你可以有多个相交:

更新缩放

我们差不多完成了。现在我们要做的,就是根据相交数组更新模型的 scale。

在调用 intersectObject 之后,我们可以测试数组的 length。

0 被视为 false,所以我们可以直接用 modelIntersects.length 作为条件。

如果它大于 0,就是 true,意味着鼠标悬停在模型上,我们应该增大缩放。否则,就是 false,意味着鼠标没有悬停在模型上,我们应该把缩放设为 1:

const tick = () =>
{
    // ...

    if(model)
    {
        const modelIntersects = raycaster.intersectObject(model)
        
        if(modelIntersects.length)
        {
            model.scale.set(1.2, 1.2, 1.2)
        }
        else
        {
            model.scale.set(1, 1, 1)
        }
    }

    // ...
}
lesson demo video preview